Skip to content

Fix: an onion path could run through the node it was addressed to - #2221

Merged
mpretty-cyro merged 1 commit into
session-foundation:devfrom
mpretty-cyro:fix/path-excludes-onion-destination
Sep 28, 2026
Merged

mpretty-cyro merged 1 commit into
session-foundation:devfrom
mpretty-cyro:fix/path-excludes-onion-destination

Conversation

@mpretty-cyro

Copy link
Copy Markdown
Collaborator

The path build never told the path manager what the request was for, so a path could contain the destination and put the last hop and the target on the same node. A node will not open a connection to itself, which breaks quic-to-quic connections on 2.12.

The symptom is currently masked server-side: service nodes now detect the self-connection and de-onion the next hop in place. The client still builds the bad path, so this is worth fixing on its own terms rather than as a live breakage.

The change

getPath has always taken a snode to exclude, and selectPath has always honoured it — the mechanism was simply never used. The one real caller now passes what was already in scope:

val path = pathOverrides ?: pathManager.getPath(
    exclude = (onionDestination as? OnionDestination.SnodeDestination)?.snode
)

OnionDestination is sealed and only SnodeDestination can collide; a ServerDestination is not a pool member, so it excludes nothing.

What this does not do

  • Avoidance, not a guarantee. If every path contains the destination, selectPath logs "No valid paths excluding requested snode, using any available path" and returns one. getPath first falls through to rebuildPaths(reusablePaths = current), which reuses the existing paths, so a rebuild need not produce a non-colliding one. Rare with 2 paths of 3 hops against a pool of hundreds. Closing it would mean a broader exclusion mechanism, deliberately not attempted here.
  • Path overrides are unaffected, because they bypass getPath entirely. Their only producer today is the path test, which already picks a destination outside the path it is testing — a property of that caller, not something enforced where the override is consumed.

Tests

Three in OnionSessionApiExecutorTest, following that file's existing mockk harness. Two of them are discriminators, verified red with the production change reverted:

test red without the fix
snode destination is excluded from the path it is reached through yes
the path chosen for a snode destination does not run through it — asserts on the path handed to onionBuilder.build, given a colliding path and a clean one to choose between yes
a server destination excludes nothing - it is not a pool member no, by construction

The third passes either way: before the change the call passes the default null, and so does the fixed code for a server destination. It guards against a future exclude that is wrong for the server case; it is not evidence for this one.

Unit suite: 306 pass, 0 fail (303 on this base plus these three).

The path build never told the path manager what the request was for, so a path could
contain the destination and put the last hop and the target on the same node. A node
will not connect to itself, which broke quic-to-quic connections on 2.12; the service
nodes now detect the self-connection and work around it, so the symptom is masked
server-side while the client still builds the path.

getPath already takes a snode to exclude and selectPath already honours it - the
mechanism was simply never used. Only a snode destination can collide, so a server
destination excludes nothing.

This is avoidance, not a guarantee: if every path contains the destination, selectPath
logs and returns one anyway. Closing that needs a different exclusion mechanism and is
deliberately not attempted here.

Path overrides bypass getPath entirely, so they are unaffected. Today their only
producer is the path test, which already picks a destination outside the path it is
testing.
@mpretty-cyro
mpretty-cyro marked this pull request as ready for review September 28, 2026 01:55
@mpretty-cyro
mpretty-cyro merged commit 90194f0 into session-foundation:dev Sep 28, 2026
5 checks passed
@mpretty-cyro
mpretty-cyro deleted the fix/path-excludes-onion-destination branch September 28, 2026 02:42
mpretty-cyro added a commit to mpretty-cyro/session-android that referenced this pull request Sep 28, 2026
Follow-ups to the destination-exclusion change in session-foundation#2221, now that it is on dev.

Snode equality is address and port, so the exclusion missed a node whose pool record
and swarm record were fetched either side of an IP or port change - the same node
under two addresses, one of which is the path's. Path selection now compares ed25519
keys, falling back to equality for a snode carrying no key material. Kept local to
path selection rather than changing Snode.equals, which the pool, the paths and the
swarm all rely on.

Also drops the claim that quic-to-quic "refuses it outright" from the comment
explaining the exclusion. That describes the service nodes' behaviour, which has since
changed - they detect the self-connection and work around it - and nothing here can
notice when it changes again. The local obligation is the durable half: a path must not
contain the node it is addressed to.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants